/* Hero: the picture as a CSS background, with an assembly layer over it.

   The order matters and is the whole lesson from the earlier attempts. The
   finished image is a plain CSS background - the browser owns decoding, the
   cover fit, resizes and the AVIF/WebP choice, so it cannot vanish. The
   canvas above it starts fully TRANSPARENT and is only raised while it is
   genuinely drawing scrambled tiles. Any failure - stale bitmap, paused rAF,
   an image that never decoded - leaves it transparent, which shows the
   finished picture rather than a hole.

   Two crops, not one scaled: the desktop file is 3:2 with its dark corner
   top-left under the headline, the mobile one is 9:16 with the light lower.
   CSS swaps them; no script watches the breakpoint for that. */

const HERO_ART = {
  desktop: { avif: '../../assets/hero/spectrum-desktop.avif', webp: '../../assets/hero/spectrum-desktop.webp' },
  mobile: { avif: '../../assets/hero/spectrum-mobile.avif', webp: '../../assets/hero/spectrum-mobile.webp' },
};

/* Deterministic jitter per tile - fixed seed, so a resize recomputes the
   grid rather than reshuffling what the visitor is looking at. */
function heroJitter(i) {
  let s = (i + 1) * 0x9e3779b1;
  s ^= s >>> 15; s = (s * 0x85ebca6b) >>> 0;
  s ^= s >>> 13; s = (s * 0xc2b2ae35) >>> 0;
  return ((s >>> 8) % 1000) / 1000;
}

function PathHero() {
  const wrapRef = React.useRef(null);

  React.useEffect(() => {
    const wrap = wrapRef.current;
    if (!wrap) return;
    const canvas = wrap.querySelector('canvas');
    const ctx = canvas.getContext('2d');
    const mq = window.matchMedia('(max-width: 1024px)');
    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    /* Reduced motion: the CSS background is already the finished picture, so
       there is nothing to do but let the copy show. */
    if (reduce) {
      document.documentElement.classList.add('sm-hero-seen');
      document.documentElement.style.setProperty('--hero-s', '1');
      return;
    }

    let mobile = mq.matches;
    let img = null;
    const load = () => {
      const src = HERO_ART[mobile ? 'mobile' : 'desktop'];
      const im = new Image();
      im.onload = () => { img = im; };
      im.onerror = () => { if (im.src.endsWith('.avif')) im.src = src.webp; };
      im.src = src.avif;
    };
    load();

    let W = 0, H = 0, cols = 4, rows = 5, tiles = [];
    let manual = false, wheelP = 0, raf = 0, prevT = 0, solveQ = 0, shown = -1;
    let dpr = Math.min(2, window.devicePixelRatio || 1);

    /* ~240px per tile on screen, capped so a 4K card gets more pieces rather
       than bigger ones. Only re-cut while the board is untouched. */
    const buildTiles = (c, r) => {
      cols = c; rows = r;
      const n = c * r;
      tiles = [];
      for (let i = 0; i < n; i++) {
        const col = i % c, row = Math.floor(i / c);
        /* Offset away from the middle, so the board opens outward. Distance
           from centre also sets the order: the picture closes in from the
           edges rather than in a scan. */
        const fx = (col + 0.5) / c - 0.5, fy = (row + 0.5) / r - 0.5;
        const dist = Math.hypot(fx, fy) / 0.71;
        const jit = heroJitter(i);
        tiles.push({
          col, row,
          dcol: fx * (1.5 + jit * 1.3), drow: fy * (1.5 + jit * 1.3),
          rot: (jit - 0.5) * 0.08,
          st: Math.min(0.5, (1 - dist) * 0.42 + jit * 0.08),
          f: 0, latch: false,
        });
      }
    };
    buildTiles(4, 5);

    const syncGrid = () => {
      let target = 240, c = 4, r = 5;
      for (let i = 0; i < 12; i++) {
        c = Math.max(3, Math.round(W / target));
        r = Math.max(4, Math.round(H / target));
        if (c * r <= 300) break;
        target *= 1.15;
      }
      if (c === cols && r === rows) return;
      if (!manual && !tiles.some((t) => t.f > 0 || t.latch)) buildTiles(c, r);
    };

    const resize = () => {
      const nw = Math.max(1, wrap.clientWidth), nh = Math.max(1, wrap.clientHeight);
      if (nw === W && nh === H && canvas.width > 1) return;
      W = nw; H = nh;
      const was = mobile;
      mobile = mq.matches;
      if (mobile !== was) load();
      const b = Math.min(2, window.devicePixelRatio || 1);
      const px = W * H;
      dpr = px * b * b > 14e6 ? Math.max(0.75, Math.sqrt(14e6 / px)) : b;
      canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
      syncGrid();
    };
    const ro = new ResizeObserver(resize);
    ro.observe(wrap);
    resize();

    /* Read the scroll from whichever element actually scrolls, in the capture
       phase - scroll events do not bubble, and window.scrollY is 0 wherever
       the window is not the scroller (an iframe preview, for one). */

    /* The canvas draws the scramble only: a dark ground plus every tile taken
       from the image at an offset. Its own opacity carries the dissolve, so
       the solved state is literally the CSS background showing through. */
    const draw = () => {
      if (!img || !W || !H) return;
      const cw = W / cols, ch = H / rows;
      const s = Math.max(img.width / W, img.height / H);
      const sw = W * s, sh = H * s;
      const sx = (img.width - sw) / 2, sy = (img.height - sh) / 2;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, W, H);
      ctx.globalAlpha = 0.42;
      ctx.drawImage(img, sx, sy, sw, sh, 0, 0, W, H);
      ctx.globalAlpha = 1;
      ctx.fillStyle = 'rgba(5,11,30,0.5)';
      ctx.fillRect(0, 0, W, H);
      for (const t of tiles) {
        const g = 1 - t.f;
        const x = t.col * cw, y = t.row * ch;
        const e = g * g * (3 - 2 * g);
        const ox = t.dcol * cw * e, oy = t.drow * ch * e;
        const inset = 2.5 * g;
        ctx.save();
        ctx.translate(x + cw / 2 + ox, y + ch / 2 + oy);
        ctx.rotate((t.rot || 0) * g);
        ctx.translate(-(x + cw / 2), -(y + ch / 2));
        ctx.beginPath();
        if (ctx.roundRect) ctx.roundRect(x + inset, y + inset, cw - inset * 2, ch - inset * 2, 10 * g);
        else ctx.rect(x + inset, y + inset, cw - inset * 2, ch - inset * 2);
        ctx.clip();
        /* Source rect is the tile's HOME position, so a scattered board still
           reads as one picture cut apart rather than noise. */
        ctx.drawImage(img, sx + (x / W) * sw, sy + (y / H) * sh, (cw / W) * sw, (ch / H) * sh, x, y, cw, ch);
        if (g > 0.01) {
          const sheen = ctx.createLinearGradient(x, y, x, y + ch);
          sheen.addColorStop(0, 'rgba(255,255,255,' + (0.09 * g).toFixed(3) + ')');
          sheen.addColorStop(0.55, 'rgba(255,255,255,0)');
          ctx.fillStyle = sheen;
          ctx.fillRect(x, y, cw, ch);
        }
        if (g > 0.01) {
          ctx.beginPath();
          if (ctx.roundRect) ctx.roundRect(x + inset, y + inset, cw - inset * 2, ch - inset * 2, 10 * g);
          else ctx.rect(x + inset, y + inset, cw - inset * 2, ch - inset * 2);
          ctx.strokeStyle = 'rgba(255,255,255,' + (0.2 * g).toFixed(3) + ')';
          ctx.lineWidth = 1;
          ctx.stroke();
        }
        ctx.restore();
      }
    };

    const latchNear = (px, py) => {
      if (mobile) return;
      const cw = W / cols, ch = H / rows;
      for (const t of tiles) {
        if (t.latch) continue;
        const g = 1 - t.f;
        const cx = t.col * cw + cw / 2 + t.dcol * cw * g, cy = t.row * ch + ch / 2 + t.drow * ch * g;
        if (Math.hypot(px - cx, py - cy) < Math.max(cw, ch) * 1.15) t.latch = true;
      }
    };
    const onMove = (e) => { if (mobile || manual) return; const b = wrap.getBoundingClientRect(); latchNear(e.clientX - b.left, e.clientY - b.top); };
    const onWheel = (e) => {
      if (mobile) return;
      e.preventDefault();
      if (!manual) {
        manual = true;
        clearTimeout(idleTo); clearInterval(autoInt);
        let sum = 0; for (const t of tiles) sum += t.f;
        wheelP = sum / tiles.length;
      }
      wheelP = Math.max(0, Math.min(1, wheelP + e.deltaY * 0.0034));
    };
    const onClick = () => {
      if (mobile) return;
      if (manual) { wheelP = wheelP > 0.5 ? 0 : 1; return; }
      tiles.forEach((t) => { t.latch = true; });
    };
    const host = wrap.parentElement || wrap;
    host.addEventListener('mousemove', onMove);
    host.addEventListener('click', onClick);
    host.addEventListener('wheel', onWheel, { passive: false });

    let autoInt = 0;
    const idleTo = setTimeout(() => {
      autoInt = setInterval(() => {
        if (mobile) return;
        const open = tiles.filter((t) => !t.latch);
        if (!open.length) { clearInterval(autoInt); return; }
        open[Math.floor(Math.random() * open.length)].latch = true;
        if (open.length > 1) open[Math.floor(Math.random() * open.length)].latch = true;
      }, 190);
    }, 1600);

    const loop = (now) => {
      const dt = prevT ? Math.min(0.05, (now - prevT) / 1000) : 1 / 60;
      prevT = now;
      if (mobile) {
        /* Transparent layer, finished CSS picture, copy shown. */
        if (shown !== 0) { shown = 0; canvas.style.opacity = '0'; }
        document.documentElement.classList.add('sm-hero-seen');
        raf = requestAnimationFrame(loop);
        return;
      }
      const scrollMode = false;
      const p = wheelP;
      for (const t of tiles) {
        const target = (scrollMode || manual) ? Math.max(0, Math.min(1, (p - t.st) / 0.45)) : (t.latch ? 1 : 0);
        const d = target - t.f;
        if (Math.abs(d) > 0.0008) t.f += d * (1 - Math.exp(-(scrollMode ? 5.7 : (manual ? 14.9 : 7)) * dt));
        else t.f = target;
      }
      let sum = 0;
      for (const t of tiles) sum += t.f;
      const avg = sum / tiles.length;

      /* Publish for the shell: the copy fades in from --hero-s and the
         headline tracking plays on html.sm-hero-seen. On mobile Home.jsx
         writes the same variable from the same scroll, so they agree. */
      const a = (Math.round(avg * 200) / 200).toFixed(3);
      if (!scrollMode && document.documentElement.style.getPropertyValue('--hero-s') !== a) document.documentElement.style.setProperty('--hero-s', a);
      if (avg > 0.88) document.documentElement.classList.add('sm-hero-seen');

      /* Raise the layer only while there is a scramble to show, and only once
         the image has decoded. Transparent is the safe state. */
      const want = img ? Math.max(0, Math.min(1, 1 - avg * 1.06)) : 0;
      if (Math.abs(want - shown) > 0.004) { shown = want; canvas.style.opacity = want.toFixed(3); }
      if (want > 0.002) { try { draw(); } catch (err) { console.error('hero assembly', err && err.message); } }
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      host.removeEventListener('mousemove', onMove);
      host.removeEventListener('click', onClick);
      host.removeEventListener('wheel', onWheel);
      clearTimeout(idleTo);
      if (autoInt) clearInterval(autoInt);
    };
  }, []);

  const layer = { position: 'absolute', inset: 0, pointerEvents: 'none' };
  const art = (file) => 'image-set(url("../../assets/hero/' + file + '.avif") type("image/avif"), url("../../assets/hero/' + file + '.webp") type("image/webp"))';

  return (
    <div ref={wrapRef} className="sm-hero-field" style={{ position: 'absolute', inset: 0, overflow: 'hidden', backgroundColor: '#07142F' }}>
      <div className="sm-hero-art sm-hero-art-desk" style={{ ...layer, backgroundImage: art('spectrum-desktop'), backgroundSize: 'cover', backgroundPosition: 'center' }}></div>
      <div className="sm-hero-art sm-hero-art-mob" style={{ ...layer, backgroundImage: art('spectrum-mobile'), backgroundSize: 'cover', backgroundPosition: 'center', display: 'none' }}></div>
      <canvas style={{ position: 'absolute', inset: 0, display: 'block', width: '100%', height: '100%', opacity: 0, transition: 'opacity .18s linear' }}></canvas>
      {/* Reading passages: over the picture and the assembly, under the copy. */}
      <div aria-hidden="true" style={{ ...layer, background: 'linear-gradient(155deg, rgba(5,10,28,0.5) 0%, rgba(5,10,28,0.13) 38%, rgba(5,10,28,0) 58%), linear-gradient(to top, rgba(4,8,24,0.88) 0%, rgba(4,8,24,0.80) 18%, rgba(4,8,24,0.70) 34%, rgba(4,8,24,0.52) 48%, rgba(4,8,24,0.22) 62%, rgba(4,8,24,0) 78%)' }}></div>
    </div>
  );
}

Object.assign(window, { PathHero });
